手写一个 Redux(二)

这个系列分为 3 个部分(这篇文章是第 2 部分):

  1. 最基本简单的 Redux 实现,以及如何与 React 结合使用
  2. 实现 React-Redux
  3. 增强 Redux 的实现,包括拆分合并 reducer,中间件等

react-redux 是 Redux 官方的 React 绑定库。

一个 UI 组件如果需要使用 Redux 来进行状态管理,需要做以下几件事:

  • 在组件初始化时内获取 store 中的状态
  • 订阅 store 内状态的改变,状态有更新则刷新组件状态
  • 在组件卸载时移除对 store 状态的订阅

上面的逻辑,是每个组件与 Redux 结合使用时都需要的。react-redux 将上面的逻辑以高阶组件的形式复用了。

connect

react-redux 的一个基本核心是 connect 函数,它的作用是将一个感知不到 Redux 存在的展示型组件进行包装,将 store 中的状态以及改变状态的能力(dispatch(action)),注入到组件中。它可能是类似下面形式的函数:

1
2
3
4
5
const connect = wrappedComponent => {
return {
/* component with state and dispatch */
};
};

加入上面提到的重复逻辑后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from 'react';
import store from './store'; // fake path

const connect = () => WrappedComponent => {
return class Connect extends React.Component {
constructor(props) {
super(props);
this.state = store.getState();
this.dispatch = store.dispatch;
}

componentDidMount() {
this.unsubscribe = store.subscribe(() => {
this.setState(store.getState());
});
}

componentWillUnmount() {
this.unsubscribe();
}

render() {
return (
<WrappedComponent {...this.props} {...this.state} {...this.dispatch} />
);
}
};
};

同时,考虑到组件可能只需要 store 中的部分 statedispatch,或者多个组件使用同一个 store,但是所需的 statedispatch 是不一样的,所以可以提供选项给组件来定制使用。为了简明易懂,新增的参数是名为 mapStateToProps mapDispatchToProps 的 2 个函数。光看函数名字就知道它的作用是什么:

  • mapStateToProps 选择 store 中的哪些状态传入给组件
  • mapDispatchToProps 选择 store 中的哪些派发动作传入给组件

这里将这 2 个参数定义为函数,是为了获得 wrappedComponent 的作用域,以便访问到 store 实例。以 mapStateToProps 函数为例,它的用法如下:

1
2
3
4
5
6
7
8
9
// state 在被包装组件的作用域内通过 store.getState() 获得
// 假如 state = { users, products }
const mapStateToProps = state => {
const { users } = state;
return {
// 只返回需要的状态:users
users,
};
};

类似地,mapDispatchToProps 函数的使用如下:

1
2
3
4
5
6
7
// dispatch 即在被包装组件作用域内访问到的 store.dispatch
const mapDispatchToProps = dispatch => {
return {
// 只返回需要的派发动作:addUser
addUser: user => dispatch({ type: 'ADD_USER', user }),
};
};

那么 connect 函数则变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// connect.js
import React from 'react';
import store from './store'; // fake path

const connect = (mapStateToProps, mapDispatchToProps) => WrappedComponent => {
const mappedState = mapStateToProps(store.getState());
const mappedDispatch = mapDispatchToProps(store.dispatch);

return class Connect extends React.Component {
constructor(props) {
super(props);
this.state = mappedState;
this.dispatch = mappedDispatch;
}

componentDidMount() {
this.unsubscribe = store.subscribe(() => {
this.setState(mapStateToProps(store.getState()));
});
}

componentWillUnmount() {
this.unsubscribe();
}

render() {
return (
<WrappedComponent {...this.props} {...this.state} {...this.dispatch} />
);
}
};
};

注意上面的 connect.js 文件,存在一个问题:store 是由 Redux 的使用者(同时也是 React-Redux 的使用者)创建的,因此 connect.js 源码文件不可能提前引入 store。所以我们需要换一种方式,让 Redux 和 React-Redux 的使用者给我们传入 store 这个参数。React 中的数据传递有 2 种方式:

  • 通过 props 传递
  • 通过 Context API 传递

我们使用 Context API 将 store 进行传递,这样组件树上的每个组件都可以获取到 store

Provider

这里使用旧版本的 Context API 来写一个 Provider 组件。

Provider 组件接收一个 store 属性,也就是使用 createStore() 创建的 store。借助 Context API,Provider 的子孙组件可以访问到 store 属性。一般的做法是将应用程序的根组件用 Provider 组件包裹起来:

1
2
3
4
5
6
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
document.getElementById('root')
);

这样,由于 connect 函数处在被包裹组件的作用域内,自然也可以通过 Context API 访问到 store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Provider.js
import React from 'react';
import PropTypes from 'prop-types';

export default class Provider extends React.Component {
static childContextTypes = {
store: PropTypes.shape({
getState: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
}).isRequired,
};

constructor(props) {
super(props);
this.store = props.store;
}
getChildContext() {
return {
store: this.store,
};
}
render() {
return this.props.children;
}
}

借助 Context API,更新后的 connect.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// connect.js
import React from 'react';

const connect = (mapStateToProps, mapDispatchToProps) => WrappedComponent => {
return class Connect extends React.Component {
static contextTypes = {
store: PropTypes.shape({
subscribe: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
getState: PropTypes.func.isRequired,
}).isRequired,
};

constructor(props, context) {
super(props, context);
this.store = context.store;
this.state = mapStateToProps(this.store.getState());
this.dispatch = mapDispatchToProps(this.store.dispatch);
}

componentDidMount() {
this.unsubscribe = this.store.subscribe(() => {
this.setState(mapStateToProps(this.store.getState()));
});
}

componentWillUnmount() {
this.unsubscribe();
}

render() {
return (
<WrappedComponent {...this.props} {...this.state} {...this.dispatch} />
);
}
};
};

上面我们实现的 React-Redux 其实还很不完善,这里先根据实际的使用场景,做以下几点简单的改进:

  1. mapStateToPropsmapDispatchToProps 都应该提供缺省值,因为有的时候某个组件可能只需要 store 的部分状态而不需要派发动作,或者反过来只需要派发动作而不需要状态,这些都是可能的使用场景。我们将参数 mapStateToProps 的默认值设为:state => ({}),这样当用户不需要 store 中的状态时,可以缺省该参数;将参数 mapDispatchToProps 的默认值设为 dispatch => ({dispatch}),这样当用户不需要 store 中的派发动作时,可以缺省该参数。
  2. connectProvider 中的 store 的 PropType 规则可以提取出来,避免代码的冗余。
  3. 目前 connect 返回的组件名都是Connect,为方便调试,调整组件的静态属性 displayName
  4. 目前,我们仅传递了 store.getState()mapStateToProps,但是很可能在筛选过滤需要的状态时,需要依据组件自身的属性进行处理。因此,可以将组件自身的属性以第 2 个参数 ownProps 传递给 mapStateToProps,同样的原因,也将自身属性传递给 mapDispatchToProps
  5. 在组件内订阅选中的 store 状态时,加入浅比较以优化性能。

简易 React-Redux 代码

需要说明的是,这里的代码,很多的细节和边缘情况没有处理,性能也没有做优化。不过了解 React-Redux 的基本的内部原理应该差不多了。这里为了方便,所有文件都放在同一文件夹里面。核心代码在 connect.jsProvider.js 这两个文件内,此外还有 2 个工具属性的文件:浅比较和 store 类型校验。

storeShape.js

1
2
3
4
5
6
7
8
9
10
11
import PropTypes from 'prop-types';

const storeShape = {
store: PropTypes.shape({
subscribe: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
getState: PropTypes.func.isRequired,
}).isRequired,
};

export default storeShape;

shallowEqual.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const shallowEqual = (objA, objB) => {
if (objA === objB) {
return true;
}

const keysA = Object.keys(objA);
const keysB = Object.keys(objB);

if (keysA.length !== keysB.length) {
return false;
}

// Test for A's keys different from B.
const hasOwn = Object.prototype.hasOwnProperty;
for (let i = 0; i < keysA.length; i++) {
if (!hasOwn.call(objB, keysA[i]) || objA[keysA[i]] !== objB[keysA[i]]) {
return false;
}
}

return true;
};

export default shallowEqual;

connect.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import React from 'react';
import shallowEqual from './shallowEqual';
import storeShape from './storeShape';

const defaultMapStateToProps = (state, ownProps) => ({});
const defaultMapDispatchToProps = (dispatch, ownProps) => ({ dispatch });

const getDisplayName = WrappedComponent =>
WrappedComponent.displayName || WrappedComponent.name || 'Component';

const connect = (mapStateToProps, mapDispatchToProps) => WrappedComponent => {
// 1. 状态和派发动作的缺省处理
if (!mapStateToProps) {
mapStateToProps = defaultMapStateToProps;
}

if (!mapDispatchToProps) {
mapDispatchToProps = defaultMapDispatchToProps;
}

return class Connect extends React.Component {
// 2. 提取 store 类型校验的代码到 storeShape.js 文件
static contextTypes = storeShape;
// 3. 为方便调试,调整组件的静态属性 displayName
static displayName = `Connect(${getDisplayName(WrappedComponent)})`;
constructor(props, context) {
super(props, context);
this.store = context.store;
// 4. 提供组件自身属性作为第 2 个参数,以便基于此做筛选需要的状态和派发动作
this.state = mapStateToProps(this.store.getState(), this.props);
this.dispatch = mapDispatchToProps(this.store.dispatch, this.props);
}

componentDidMount() {
this.unsubscribe = this.store.subscribe(() => {
const mappedState = mapStateToProps(this.store.getState(), this.props);
// 5. 在组件内订阅选中的 store 状态时,加入浅比较以优化性能。
if (shallowEqual(this.state, mappedState)) {
return;
}
this.setState(mappedState);
});
}

componentWillUnmount() {
this.unsubscribe();
}

render() {
return (
<WrappedComponent {...this.props} {...this.state} {...this.dispatch} />
);
}
};
};

export default connect;

Provider.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from 'react';
import storeShape from './storeShape';

export default class Provider extends React.Component {
static childContextTypes = storeShape;

constructor(props) {
super(props);
this.store = props.store;
}
getChildContext() {
return {
store: this.store,
};
}
render() {
return this.props.children;
}
}

参考链接: